Go back home
Making a blogging system with Phoenix and React [Part 2]

Making a blogging system with Phoenix and React [Part 2]

making-a-blogging-system-with-phoenix-and-react-[part-2]

Welcome to part 2 !
Last time we set up the basic Milkdown logic. In this post, we are scaffolding our posts and adding more functionality to Milkdown.

  • Setting up Milkdown (core and plugins)

  • Scaffold the blog posts (You are here)

  • Deal with image uploads

  • Create the API routes and functionalities

Scaffold the blog posts

We're going to create a pretty simple post schema:

  • We need a title, content, tags, and a feature image.

The feature image will be implemented via Waffle Ecto and uploaded on a DigitalOcean Object Storage (or local storage if you want.)

Let's mix it : mix phx.gen.html Blog Post posts title:string content:text tags:string featured_image:string

We're defining a Blog context that will hold our posts and ask Phoenix to please give us everything we need to display them in the browser1.
Since we asked for content to be of type text, we will have a textarea input generated for us. With the work done prior to all this, it should already be a markdown editor.

This can be verified by adding our post routes to our router :

  live "/posts", PostLive.Index, :index
  live "/posts/new", PostLive.Index, :new
  live "/posts/:id/edit", PostLive.Index, :edit
  live "/posts/:id", PostLive.Show, :show
  live "/posts/:id/show/edit", PostLive.Show, :edit

and navigating to /posts/new on your app.

We should be greeted by a form. Only there is a slight problem. We created a Phoenix Liveview and, by design, Liveview will re-render any time the form is changed since it triggers validation. While this is fine for form inputs that are controlled by Liveview, it means that any time we change data somewhere our Markdown value will be reset. Even worse, our markdown has disappeared !

Our current HTML DOM for the posts markdown looks like this :
content-wrapper
|-- label
|-- content-mkdown

That's because Phoenix has no idea what to do with content-mkdown. After all, it's not an input !
Instead we are going to create a circular data-feed of the markdown with the help of 2 other divs:

  1. a hidden "content" input, which is what will be sent to the database

  2. a "dummy" div, also hidden, in which we will duplicate the content of our markdown

Why can't we directly save to a hidden input? Because our markdown (which is actually HTML) would be updated every single time a key is pressed.
Instead we will save the HTML as HTML in a hidden div and use the "on_change" hook to clone its value into the content input, effectively sending it up to the server.
Then, on mount, we get the value of the content input and clone it into the markdown

Luckily Phoenix makes this pretty easy for us by exposing the value of the input.

First let's modify our component, adding both of our inputs:

#mkdn_web/components/core_components.ex
  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div phx-feedback-for={@name} phx-hook="MarkdownEditor" id={@id <> "-wrapper"}>
      <.label for={@id}><%= @label %></.label>
+     <input
+       id={@id}
+       name={@name}
+       type="hidden"
+       value={Phoenix.HTML.Form.normalize_value("textarea", @value)}
+       {@rest}
+     />
      <div id={@id <> "-mkdown"}></div>
+     <div id={@id <> "-dummy"} class="hidden" aria-hidden="true"><%= Phoenix.HTML.raw(@value) %></div>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

Here we make use of the Phoenix.HTML module to make sure we get the value in a format that suits our needs.

Form.normalize_value/22 with the "textarea" param ensures that our newlines don't disappear when passed to the input.
raw/1 prevents the HTML values from getting escaped, aka not a rendered as a string.

Then back to our markdown.js to implement what boils down to "save"

function makeEditor(dom, defaultValue = "") {
  // to obtain the editor instance we need to store a reference of the editor.
  const MakeEditor = Editor.make()
    .config((ctx) => {
      ctx.set(rootCtx, dom);
      ctx.update(editorViewOptionsCtx, (prev) => ({
        ...prev,
        attributes: {
          class:
            "min-h-[128px] p-2 mt-2 block border w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 border-zinc-300 focus:border-zinc-400",
        },
      }));
+     // We configure a listener to update our textarea onChange
+     const listener = ctx.get(listenerCtx);
+     listener.markdownUpdated((_ctx, markdown, prev) => {
+       if (markdown !== prev) updateTextarea(dom, markdown);
+     });
      ctx.set(defaultValueCtx, defaultValue);
    })
    .config(nord)
    .use(commonmark)
    .use(gfm)
    .use(clipboard)
+    .use(listener)
    .create();
}

And finally, we edit our Hooks in app.js to give the markdown editor its default values

Hooks.MarkdownEditor = {
  mounted() {
    // We register a Milkdown editor for every textarea in our form.
    // We also instantiate a value if there is one.
    const mkdownId = this.el.children[2].id;
+   const dummy = this.el.children[3];
    makeEditor(document.querySelector(`#${mkdownId}`), {
      type: "html",
+     dom: document.querySelector(`#${dummy.id}`),
    });
  },
  updated() {
    // Same thing here
    const mkdownId = this.el.children[2].id;
+   const textarea = this.el.children[1];
    // fill it
    const dummy = this.el.children[3];
    makeEditor(document.querySelector(`#${mkdownId}`), {
      type: "html",
+     dom: document.querySelector(`#${dummy.id}`),
    });
  },
};

And that's all for Milkdown, we should now have a functional fancy markdown editor that can output HTML!
In the next part, we'll go over how to upload and save our images. See you there :)

1

Phoenix Hexdocs: mix phx.gen.html | https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Html.html

2

Phoenix HTML Hexdocs https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#normalize_value/2